iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Software Development

Kotlin Ktor 實戰 101系列 第 6

Kotlin Ktor 實戰 101 Day 06 路徑參數、Query Parameters 與匹配規則

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260909/20121948LfQo3aVCtG.jpg

上一篇結尾留了幾件事,path 參數 {id} 和 query 參數怎麼拿、怎麼驗,還有 /todos/todos/ 為什麼預設是 2 條路徑、想讓它們走同一條要怎麼做,這篇全部處理,Todo API 也照著主線長大,加上取單筆待辦的端點 GET /todos/{id}GET /todos 順便支援 ?limit=N,最後有一個實驗,day 05 說過那個尾斜線測試「會第一時間告訴我們行為變了」,這篇會親眼看到這件事發生

這篇要完成什麼

  • 幫 Todo API 加上取單筆待辦的端點 GET /todos/{id}GET /todos 支援 ?limit=N
  • 弄清楚路徑參數和 query 參數怎麼拿、怎麼驗,400 和 404 怎麼分
  • 看 Ktor 匹配規則的預設行為,404 之外還有 405
  • 實驗 IgnoreTrailingSlash,處理 day 05 留下的尾斜線問題
  • 對照 Relix 手刻 405、trailing slash、路徑參數時做過的決策

先寫測試

參數的邊界事先就想得清楚,測試一樣先寫,在 src/test/kotlin/com/cashwu/todo/TodoRoutesTest.kt 的 class 裡加上 7 個測試,import 區多一行 io.ktor.client.request.post,最後一個測試要用 client.post

@Test
fun `todo by id responds single todo`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos/1")

    assertEquals(HttpStatusCode.OK, response.status)
    assertEquals("買牛奶", response.bodyAsText())
}

@Test
fun `todo by unknown id responds not found`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos/999")

    assertEquals(HttpStatusCode.NotFound, response.status)
}

@Test
fun `todo by non numeric id responds bad request`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos/abc")

    assertEquals(HttpStatusCode.BadRequest, response.status)
}

@Test
fun `todos with limit query responds limited todos`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos?limit=2")

    assertEquals(HttpStatusCode.OK, response.status)
    assertEquals("買牛奶\n繳電費", response.bodyAsText())
}

@Test
fun `todos with non numeric limit responds bad request`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos?limit=abc")

    assertEquals(HttpStatusCode.BadRequest, response.status)
}

@Test
fun `todos with negative limit responds bad request`() = testApplication {
    application {
        module()
    }

    val response = client.get("/todos?limit=-1")

    assertEquals(HttpStatusCode.BadRequest, response.status)
}

@Test
fun `post todos responds method not allowed`() = testApplication {
    application {
        module()
    }

    val response = client.post("/todos")

    assertEquals(HttpStatusCode.MethodNotAllowed, response.status)
}

前 6 個圍繞新功能的行為和邊界,拿存在的 id 回 200 和那筆待辦、拿不存在的 id 回 404、拿非數字 id 回 400、帶 ?limit=2 只回前 2 筆。limit 不是數字或小於 0 也回 400,不讓無效輸入悄悄變成「全部拿」,更不能一路丟出 500

最後一個比較特別,打 POST /todos,期望 405。這篇從頭到尾不會寫任何 POST 的程式碼,這個測試跟 day 05 的尾斜線測試是同一種用途,把框架的預設行為用測試確認下來,path 對到了但 method 沒對到時,Ktor 回的是 405 不是 404

實作

src/main/kotlin/com/cashwu/todo/Application.ktrouting 區塊改成下面這樣,get("/") 那條保留,day 05 原本那條 get("/todos") 換成下面這 2 條,不是留著再往後加,import 區多一行 io.ktor.http.HttpStatusCode

get("/todos") {
    val limitText = call.request.queryParameters["limit"]
    val limit = if (limitText == null) todos.size else limitText.toIntOrNull()?.takeIf { it >= 0 }
    if (limit == null) {
        call.respondText("limit 要是 0 以上的整數", status = HttpStatusCode.BadRequest)
        return@get
    }
    call.respondText(todos.take(limit).joinToString("\n"))
}
get("/todos/{id}") {
    val id = call.parameters["id"]?.toIntOrNull()
    if (id == null) {
        call.respondText("id 要是數字", status = HttpStatusCode.BadRequest)
        return@get
    }
    val todo = todos.getOrNull(id - 1)
    if (todo == null) {
        call.respondText("找不到 id $id 的待辦", status = HttpStatusCode.NotFound)
        return@get
    }
    call.respondText(todo)
}

改動不大,但每一行背後都有一個要弄清楚的行為,逐段拆

同一條路徑註冊兩次,只有先註冊的會跑

上面特別提了一句「換掉不是往後加」,是因為 Ktor 對「同一個 path 加同一個 method 註冊 2 次」不會報錯也不會警告,比對到的時候走先註冊的那一條,後面那條一次都不會被執行

真的變成 2 條的時候,症狀是 3 個跟 limit 有關的測試一起倒,?limit=2 回的是完整清單、?limit=abc 沒有回 400,但 GET /todos/{id} 那幾個測試全過。看起來像 query 參數這段寫錯,實際上是這段程式碼根本沒跑到,第 1 個要看的地方不是 handler 裡面,是 routing 區塊裡同一條 path 出現了幾次

路徑參數,{id} 什麼都接

get("/todos/{id}") 路徑裡的 {id} 是參數 segment,這一段不比對固定字串,那個位置出現什麼它就接什麼,/todos/1/todos/999/todos/abc 全部都會進到這個 handler,要注意的是,匹配只看「這個位置有一段」,內容合不合理它不管,驗證是 handler 的責任

補一句優先序的事,路由匹配時具體的 segment 比參數 segment 優先,之後如果註冊了 get("/todos/all") 這種固定路徑,/todos/all 會走它,不會被 {id} 攔走,目前 /todos 底下只有 {id} 這一條,所以 abc 也進了 handler,由裡面的驗證擋下

call.parameters["id"] 拿到的型別是 String?,不是數字,URL 的 path 本來就只是字串,框架不知道你的 id 是數字、UUID 還是別的東西,型別轉換與驗證得自己來,toIntOrNull() 轉不動就回 null,配上 early return 是最直接的寫法,return@get@get 是 label,lambda 裡的 return 要指名跳出哪一層,這裡跳出的是 get(...) 的 handler,後面的程式碼不會再跑

400 和 404 是兩個不同的錯

id 不是數字,跟 id 是數字但查不到,是 2 件事,前者是請求本身格式有問題,回 400 Bad Request,後者是請求格式沒問題,只是資源不存在,回 404 Not Found,混在一起回同一個狀態碼,client 就分不出「我打錯了」和「東西不在」,所以實作裡是 2 段獨立的檢查、2 個不同的狀態碼,測試也各測各的

id 是「清單第幾筆」,暫時的

todos.getOrNull(id - 1) 把 id 當成清單的第幾筆,從 1 開始算,這是刻意的簡化,跟 day 05 把資料放在 mutableList 是一樣的,目前的資料就是一個記憶體裡的清單,沒有真正的 id 欄位,day 20 之後換上資料庫,id 才會是資料庫給的,getOrNull 超出範圍回 null,正好接上 404 那段檢查

query 參數,一樣是字串、一樣自己驗

query 參數從 call.request.queryParameters 拿,跟路徑參數一樣拿到 String?,沒帶這個參數就是 null,沒有 limit 時取完整清單,有值就用 toIntOrNull() 轉型,再用 takeIf { it >= 0 } 擋掉負數,任何一步失敗都回 400,避免 ?limit=abc 被當成「全部拿」,也避免 take(-1) 丟出例外變成 500

這裡先用直接的 if 寫清楚單一欄位的規則,後面會把輸入驗證集中到 RequestValidation,重點是讓規則離開 handler,不是等到那時才開始拒絕無效輸入

跑起來看結果

./gradlew test

實測的結果是

> Task :test

ApplicationTest > root path responds hello() PASSED

ApplicationTest > unknown path responds not found() PASSED

...

BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html

12 個測試通過,7 個是這篇新增的,day 05 那個尾斜線測試也還在名單裡,等一下實驗時它會是主角,接著 ./gradlew run 跑起 server,另開視窗逐一用 curl 確認。先取單筆

curl http://localhost:8080/todos/1
買牛奶

打一個不存在的 id,用 -w 順便印出狀態碼

curl -w "\n%{http_code}" http://localhost:8080/todos/999
找不到 id 999 的待辦
404

換非數字

curl -w "\n%{http_code}" http://localhost:8080/todos/abc
id 要是數字
400

query 參數,URL 要用引號包起來,? 在 shell 裡有特殊意義

curl "http://localhost:8080/todos?limit=2"
買牛奶
繳電費

無效的 limit 直接回 400

curl "http://localhost:8080/todos?limit=-1" -w "\n%{http_code}"
limit 要是 0 以上的整數
400

最後對 /todos 發一個 POST

curl -i -X POST http://localhost:8080/todos

回應的前 2 行是

HTTP/1.1 405 Method Not Allowed
Content-Length: 0

405 是誰回的

我們沒寫半行 POST 相關的程式碼,這個 405 是 Ktor 的預設行為,路由匹配時 path 和 method 是分開看的,/todos 這個 path 有註冊,只是沒有 POST 的 handler,這種「path 對到了但 method 沒對到」的情況回 405 Method Not Allowed,而不是跟「path 根本不存在」一樣回 404,這個區分是有語意的,404 告訴 client 資源不存在,405 告訴 client 資源在、只是動詞用錯了

IgnoreTrailingSlash 實驗

day 05 留了一個測試,打 /todos/ 期望 404,當時說「之後 day 06 動到它時,這個測試會第一時間告訴我們行為變了」,現在就來動它

Ktor 預設把 /todos/todos/ 當成 2 條路徑,想讓它們一視同仁,官方給的開關是 IgnoreTrailingSlash 這個 plugin,在 Application.ktmodule() 裡、routing 區塊前面加一行

install(IgnoreTrailingSlash)

要搭配 2 行 import,io.ktor.server.application.installio.ktor.server.routing.IgnoreTrailingSlash

它跟路由的匹配規則綁在一起,所以住在 io.ktor.server.routing 底下,遇到 unresolved reference 先檢查 import 是不是猜錯了 package

裝好之後跑 ./gradlew test,day 05 那個測試立刻失敗

TodoRoutesTest > todos path with trailing slash responds not found() FAILED
    org.opentest4j.AssertionFailedError: expected: <404 Not Found> but was: <200 OK>

/todos/ 從 404 變成 200,裝了這個 plugin,/todos//todos 就一視同仁了,這正是 day 05 說的那件事,用測試把預設行為固定下來,行為一變,測試第一時間告訴你,它失敗不代表程式壞了,代表有一條規格被改動了,接下來要做的是決定,這個改動是你要的就改測試,不是就把改動拿掉

這個系列的決定,維持嚴格的尾斜線

實驗做完,把 install(IgnoreTrailingSlash) 和那 2 行 import 拿掉,維持 Ktor 預設的嚴格行為,再跑一次 ./gradlew test 確認 12 個測試回到全部通過,理由很單純,API 對外的路徑行為明確一點比較好,/todos 就是 /todos

這是取捨,不是唯一正解,等一下會看到 Relix 選了另一邊,重點是知道有這個開關,也知道測試會保護你做這種決定,哪天真要開,加一行、改一個測試就好,影響範圍全在掌握裡

跟 Relix 的對照

這篇碰到的 3 件事,405、trailing slash、路徑參數,Relix 都手刻過,對照起來每一個都能看出框架的預設替你做了什麼決定

  • 405,Relix 在 day 08 把 Map 路由升級成 Router 時,「path 對到但 method 沒對到要回什麼」是自己要想到的決策,用 sealed class 在 3 種匹配結果裡把 NotFound 和 MethodNotAllowed 分開,405 回應該帶的 Allow header 也是自己組的,Ktor 這邊什麼都不用寫,預設就回 405,這篇只是用一個測試把它確認下來,不過有個差異,實測 Ktor 預設的 405 回應沒帶 Allow header,而 RFC 9110 明確要求 405 必須帶這個 header,手刻版反而符合規範
  • trailing slash,兩邊預設方向相反,Relix 的 Router 在 day 08 用 normalizePath 把尾斜線統一處理掉,/hello/hello/ 走同一條,選的是寬鬆策略,Ktor 預設嚴格,要一視同仁得自己裝 IgnoreTrailingSlash,day 05 對照段講過「框架的預設跟你的直覺不一定同一邊」,這句話更具體了,2 個框架對同一件事的預設可以完全相反,所以才要用測試把你依賴的那個預設固定住
  • 路徑參數,Relix 在 day 09 手刻了整條路,把樣板和請求路徑切成 segments 逐段比對、把參數提取出來塞進 call、還要處理靜態路由優先於參數路由,Ktor 的 call.parameters["id"] 拿到的就是同一件事的成品,segment 比對、參數提取、優先序,路由樹裡全做完了,不過有一件事兩邊一模一樣,參數拿到手永遠是 String,驗證是使用者的責任,這條線框架不會替你跨過去,因為只有你知道 id 該長什麼樣

小結

Todo API 現在用 200、400、404 分清楚正常結果、格式錯誤與找不到資料,非法 limit 也不會再被當成完整清單或變成 500,路由層另外固定了 405 與尾斜線的嚴格預設,之後重構或換路由寫法,測試會直接指出哪些行為被改動


下一篇

routing 區塊現在有 3 條路由,全部擠在 module() 裡,之後每篇都會再往上加,加到後面這個函式會變成一長串,想找某條路由得從頭看到尾,下一篇講路由的組織方式,route() 分組、把路由抽成 extension function,讓 Todo API 的路由有自己的檔案,module() 回到只負責組裝


參考資料


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Ktor 實戰 101 Day 05 Routing 基礎,Todo API 的第一個端點
下一篇
Kotlin Ktor 實戰 101 Day 07 路由組織與拆檔
系列文
Kotlin Ktor 實戰 1017
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言